Compare commits
No commits in common. "aa020bfdd867c8a727d68144032bd7602982f1f8" and "83437e76dd7d9c0395e91136893bef161be6ae36" have entirely different histories.
aa020bfdd8
...
83437e76dd
|
|
@ -1,188 +0,0 @@
|
||||||
# 모달 자동 검증 설계
|
|
||||||
|
|
||||||
## 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;
|
|
||||||
```
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||||
import { X, RotateCw, ChevronDown } from "lucide-react";
|
import { X, RefreshCw, ChevronDown } from "lucide-react";
|
||||||
import { useTabStore, selectTabs, selectActiveTabId, Tab } from "@/stores/tabStore";
|
import { useTabStore, selectTabs, selectActiveTabId, Tab } from "@/stores/tabStore";
|
||||||
import { menuScreenApi } from "@/lib/api/screen";
|
import { menuScreenApi } from "@/lib/api/screen";
|
||||||
import {
|
import {
|
||||||
|
|
@ -350,33 +350,33 @@ export function TabBar() {
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||||
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-9 shrink-0 cursor-pointer items-center gap-1 rounded-t-lg border border-b-0 px-3 text-sm select-none",
|
||||||
isActive
|
isActive
|
||||||
? "border-border bg-white text-foreground z-10 mb-[-1px] h-[30px]"
|
? "border-border bg-white text-foreground z-10"
|
||||||
: "border-transparent 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",
|
||||||
)}
|
)}
|
||||||
style={{ width: TAB_WIDTH, touchAction: "none", ...animStyle }}
|
style={{ width: TAB_WIDTH, touchAction: "none", ...animStyle }}
|
||||||
title={tab.title}
|
title={tab.title}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate text-[11px] font-medium">{tab.title}</span>
|
<span className="min-w-0 flex-1 truncate text-xs font-medium">{tab.title}</span>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center">
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); refreshTab(tab.id); }}
|
onClick={(e) => { e.stopPropagation(); refreshTab(tab.id); }}
|
||||||
className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-4 w-4 items-center justify-center rounded-sm transition-colors"
|
className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-5 w-5 items-center justify-center rounded-sm transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCw className="h-2.5 w-2.5" />
|
<RefreshCw className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
|
onClick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 items-center justify-center rounded-sm transition-colors",
|
"text-muted-foreground hover:bg-destructive/10 hover:text-destructive flex h-5 w-5 items-center justify-center rounded-sm transition-colors",
|
||||||
!isActive && "opacity-0 group-hover:opacity-100",
|
!isActive && "opacity-0 group-hover:opacity-100",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<X className="h-2.5 w-2.5" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -389,20 +389,19 @@ export function TabBar() {
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="border-border bg-muted/30 relative flex h-[33px] shrink-0 items-end gap-[2px] overflow-hidden px-1.5"
|
className="border-border bg-muted/30 flex h-[37px] shrink-0 items-end gap-[2px] overflow-hidden border-b px-2 pt-1"
|
||||||
onDragOver={handleBarDragOver}
|
onDragOver={handleBarDragOver}
|
||||||
onDragLeave={handleBarDragLeave}
|
onDragLeave={handleBarDragLeave}
|
||||||
onDrop={handleBarDrop}
|
onDrop={handleBarDrop}
|
||||||
>
|
>
|
||||||
<div className="border-border pointer-events-none absolute inset-x-0 bottom-0 z-0 border-b" />
|
|
||||||
{displayVisible.map((tab, i) => renderTab(tab, i))}
|
{displayVisible.map((tab, i) => renderTab(tab, i))}
|
||||||
|
|
||||||
{hasOverflow && (
|
{hasOverflow && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button className="bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground flex h-7 shrink-0 items-center gap-0.5 rounded-t-md border border-b-0 border-transparent px-2 text-[11px] font-medium transition-colors">
|
<button className="bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground flex h-9 shrink-0 items-center gap-1 rounded-t-lg border border-b-0 border-transparent px-3 text-xs font-medium transition-colors">
|
||||||
+{displayOverflow.length}
|
+{displayOverflow.length}
|
||||||
<ChevronDown className="h-2.5 w-2.5" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="max-h-[300px] overflow-y-auto">
|
<DropdownMenuContent align="end" className="max-h-[300px] overflow-y-auto">
|
||||||
|
|
@ -426,10 +425,10 @@ export function TabBar() {
|
||||||
{ghostStyle && draggedTab && (
|
{ghostStyle && draggedTab && (
|
||||||
<div
|
<div
|
||||||
style={ghostStyle}
|
style={ghostStyle}
|
||||||
className="border-primary/50 bg-background rounded-t-md border border-b-0 px-3 shadow-lg"
|
className="border-primary/50 bg-background rounded-t-lg border border-b-0 px-3 shadow-lg"
|
||||||
>
|
>
|
||||||
<div className="flex h-full items-center">
|
<div className="flex h-full items-center">
|
||||||
<span className="truncate text-[11px] font-medium">{draggedTab.title}</span>
|
<span className="truncate text-xs font-medium">{draggedTab.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -44,14 +44,7 @@ function Button({
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
return (
|
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||||
<Comp
|
|
||||||
data-slot="button"
|
|
||||||
data-variant={variant || "default"}
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button, buttonVariants };
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ 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>> = ({
|
||||||
|
|
@ -83,18 +82,6 @@ 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) {
|
||||||
|
|
@ -138,7 +125,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={mergedRef}
|
ref={ref}
|
||||||
onInteractOutside={handleInteractOutside}
|
onInteractOutside={handleInteractOutside}
|
||||||
onFocusOutside={handleFocusOutside}
|
onFocusOutside={handleFocusOutside}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -169,7 +156,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 data-slot="dialog-footer" className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 shrink-0", className)} {...props} />
|
<div 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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
"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]);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue