feat: Enhance modal button behavior and validation feedback
- Updated modal button handling to disable all buttons by default, with exceptions for specific button types (e.g., cancel, close, delete). - Introduced a new validation mechanism that visually indicates empty required fields with red borders and error messages after a delay. - Improved the `useDialogAutoValidation` hook to manage button states based on field validation, ensuring a smoother user experience. - Added CSS animations to prevent flickering during validation state changes. Made-with: Cursor
This commit is contained in:
parent
dca89a698f
commit
eb2bd8f10f
|
|
@ -30,14 +30,20 @@
|
||||||
│ │ │
|
│ │ │
|
||||||
│ └─ 3단계: 버튼 비활성화 │
|
│ └─ 3단계: 버튼 비활성화 │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ├─ 대상: data-variant="default" 인 버튼 │
|
│ ├─ 기본: 모달 내 모든 <button> 비활성화 │
|
||||||
│ │ (저장, 등록, 수정, 확인 등 — variant 미지정 = default) │
|
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ├─ 제외: outline, ghost, destructive, secondary │
|
│ ├─ 제외 (항상 활성): 아래 조건에 해당하는 버튼 │
|
||||||
│ │ (취소, 닫기, X, 삭제 등) │
|
│ │ ├─ 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개인 모달 (자동 비활성) │
|
||||||
|
|
@ -79,27 +85,36 @@ if (!hasRequiredMark) return;
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 버튼 식별: data-variant 속성 기반
|
## 4. 버튼 비활성화: 기본 비활성 + 허용 목록 방식
|
||||||
|
|
||||||
### 원리
|
### 원리
|
||||||
|
|
||||||
shadcn Button 컴포넌트의 `variant` 값을 `data-variant` 속성으로 DOM에 노출한다.
|
모달 내 모든 `<button>` 요소를 기본 비활성화하고,
|
||||||
텍스트 매칭 없이 버튼의 역할을 식별할 수 있다.
|
안전한 버튼(취소, 닫기, 삭제, 폼 컨트롤 등)만 허용 목록으로 제외한다.
|
||||||
|
저장 버튼의 구현 방식(shadcn Button / 네이티브 button)에 관계없이 동작한다.
|
||||||
|
|
||||||
### 비활성화 대상
|
### 허용 목록 (항상 활성 유지)
|
||||||
|
|
||||||
| data-variant | 용도 | 훅 동작 |
|
| 셀렉터 | 용도 |
|
||||||
|:---:|------|:---:|
|
|------|------|
|
||||||
| `default` | 저장, 등록, 수정, 확인 | 비활성화 대상 |
|
| `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 비저장 액션 |
|
||||||
|
|
||||||
### 제외 (건드리지 않음)
|
### 비활성화 대상 (허용 목록에 해당하지 않는 모든 버튼)
|
||||||
|
|
||||||
| data-variant | 용도 |
|
| 예시 | 이유 |
|
||||||
|:---:|------|
|
|------|------|
|
||||||
| `outline` | 취소 |
|
| `<Button>저장</Button>` (data-variant="default") | shadcn 기본 버튼 |
|
||||||
| `ghost` | 닫기, X 버튼 |
|
| `<button data-action-type="save">` | ButtonPrimary 저장 액션 |
|
||||||
| `destructive` | 삭제 |
|
| `<button data-action-type="submit">` | ButtonPrimary 제출 액션 |
|
||||||
| `secondary` | 보조 액션 |
|
| 기타 variant/role 없는 `<button>` | 알 수 없는 버튼은 안전하게 차단 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -128,14 +143,14 @@ useDialogAutoValidation 실행
|
||||||
초기 검증 실행 (50ms 후)
|
초기 검증 실행 (50ms 후)
|
||||||
│
|
│
|
||||||
├─ 빈 필수 필드 발견
|
├─ 빈 필수 필드 발견
|
||||||
│ ├─ 해당 input에 border-destructive 클래스 추가
|
│ ├─ 해당 input에 data-validation-empty 속성 추가 (CSS 지연 테두리)
|
||||||
│ ├─ input 아래에 "필수 입력 항목입니다" 에러 메시지 주입
|
│ ├─ input 아래에 "필수 입력 항목입니다" 에러 메시지 주입
|
||||||
│ └─ data-variant="default" 버튼 비활성화
|
│ └─ 허용 목록 외 모든 버튼 비활성화
|
||||||
│
|
│
|
||||||
├─ 모든 필수 필드 입력됨
|
├─ 모든 필수 필드 입력됨
|
||||||
│ ├─ 에러 메시지 제거
|
│ ├─ 에러 메시지 제거
|
||||||
│ ├─ border-destructive 제거
|
│ ├─ data-validation-empty 속성 제거
|
||||||
│ └─ 버튼 활성화
|
│ └─ 전체 버튼 활성화
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
이벤트 리스너 등록
|
이벤트 리스너 등록
|
||||||
|
|
@ -150,9 +165,10 @@ useDialogAutoValidation 실행
|
||||||
클린업
|
클린업
|
||||||
├─ 이벤트 리스너 제거
|
├─ 이벤트 리스너 제거
|
||||||
├─ MutationObserver 해제
|
├─ MutationObserver 해제
|
||||||
|
├─ 폴링 타이머 해제
|
||||||
├─ 주입된 에러 메시지 제거
|
├─ 주입된 에러 메시지 제거
|
||||||
├─ 버튼 비활성화 상태 복원
|
├─ 버튼 비활성화 상태 복원
|
||||||
└─ border-destructive 클래스 제거
|
└─ data-validation-empty 속성 제거
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -163,7 +179,9 @@ useDialogAutoValidation 실행
|
||||||
|------|------|
|
|------|------|
|
||||||
| `frontend/lib/hooks/useDialogAutoValidation.ts` | 자동 검증 훅 본체 |
|
| `frontend/lib/hooks/useDialogAutoValidation.ts` | 자동 검증 훅 본체 |
|
||||||
| `frontend/components/ui/button.tsx` | data-variant 속성 노출 |
|
| `frontend/components/ui/button.tsx` | data-variant 속성 노출 |
|
||||||
| `frontend/components/ui/dialog.tsx` | DialogContent에서 훅 호출 |
|
| `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 애니메이션 지연 (깜빡임 방지) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -424,4 +424,43 @@ select {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 모달 필수 입력 검증 - CSS 딜레이로 깜빡임 방지 ===== */
|
||||||
|
@keyframes validationFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes validationBorderIn {
|
||||||
|
from { border-color: inherit; }
|
||||||
|
to { border-color: hsl(var(--destructive)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 빈 필수 필드 테두리: 500ms 딜레이 후 빨간색 */
|
||||||
|
[data-validation-empty] {
|
||||||
|
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;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-validation-interacted] [data-auto-validation-error] {
|
||||||
|
opacity: 1;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== End of Global Styles ===== */
|
/* ===== End of Global Styles ===== */
|
||||||
|
|
|
||||||
|
|
@ -1286,6 +1286,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
|
|
||||||
const externalLabelComponent = needsExternalLabel ? (
|
const externalLabelComponent = needsExternalLabel ? (
|
||||||
<label
|
<label
|
||||||
|
htmlFor={component.id}
|
||||||
className="text-sm font-medium leading-none"
|
className="text-sm font-medium leading-none"
|
||||||
style={{
|
style={{
|
||||||
fontSize: style?.labelFontSize || "14px",
|
fontSize: style?.labelFontSize || "14px",
|
||||||
|
|
@ -1336,6 +1337,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
isHorizLabel ? (
|
isHorizLabel ? (
|
||||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||||
<label
|
<label
|
||||||
|
htmlFor={component.id}
|
||||||
className="text-sm font-medium leading-none"
|
className="text-sm font-medium leading-none"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
|
||||||
|
|
@ -83,17 +83,17 @@ const DialogContent = React.forwardRef<
|
||||||
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
|
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
|
||||||
const scoped = !!container;
|
const scoped = !!container;
|
||||||
|
|
||||||
// 모달 자동 검증용 내부 ref
|
// state 기반 ref: DialogPrimitive.Content 마운트/언마운트 시 useEffect 재실행 보장
|
||||||
const internalRef = React.useRef<HTMLDivElement>(null);
|
const [contentNode, setContentNode] = React.useState<HTMLDivElement | null>(null);
|
||||||
const mergedRef = React.useCallback(
|
const mergedRef = React.useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
internalRef.current = node;
|
setContentNode(node);
|
||||||
if (typeof ref === "function") ref(node);
|
if (typeof ref === "function") ref(node);
|
||||||
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
},
|
},
|
||||||
[ref],
|
[ref],
|
||||||
);
|
);
|
||||||
useDialogAutoValidation(internalRef);
|
useDialogAutoValidation(contentNode);
|
||||||
|
|
||||||
const handleInteractOutside = React.useCallback(
|
const handleInteractOutside = React.useCallback(
|
||||||
(e: any) => {
|
(e: any) => {
|
||||||
|
|
@ -152,7 +152,7 @@ const DialogContent = React.forwardRef<
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
<DialogPrimitive.Close data-dialog-close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,10 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||||
)}
|
)}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<span className="truncate flex-1 text-left">
|
<span
|
||||||
|
className="truncate flex-1 text-left"
|
||||||
|
{...(selectedLabels.length === 0 ? { "data-placeholder": placeholder } : {})}
|
||||||
|
>
|
||||||
{selectedLabels.length > 0
|
{selectedLabels.length > 0
|
||||||
? multiple
|
? multiple
|
||||||
? `${selectedLabels.length}개 선택됨`
|
? `${selectedLabels.length}개 선택됨`
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, type RefObject } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTabStore } from "@/stores/tabStore";
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
|
|
||||||
const ERROR_ATTR = "data-auto-validation-error";
|
const ERROR_ATTR = "data-auto-validation-error";
|
||||||
const DISABLED_ATTR = "data-validation-disabled";
|
const DISABLED_ATTR = "data-validation-disabled";
|
||||||
const ACTION_BTN_SELECTOR = '[data-variant="default"]';
|
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모달 자동 폼 검증 훅
|
* 모달 자동 폼 검증 훅
|
||||||
|
|
@ -14,31 +31,27 @@ const ACTION_BTN_SELECTOR = '[data-variant="default"]';
|
||||||
* - useTabStore.mode === "user" (사용자 모드)
|
* - useTabStore.mode === "user" (사용자 모드)
|
||||||
* - 필수 필드(label 내 <span>*</span>)가 1개 이상 존재
|
* - 필수 필드(label 내 <span>*</span>)가 1개 이상 존재
|
||||||
*
|
*
|
||||||
* 동작:
|
* 지원 요소:
|
||||||
* - Label 내부 <span> 안의 * 문자로 필수 필드 자동 탐지
|
* - input, textarea, select (네이티브 폼 요소)
|
||||||
* - 빈 필수 필드에 빨간 테두리 + 에러 메시지 주입
|
* - button[role="combobox"] (Radix Select / V2Select 드롭다운)
|
||||||
* - data-variant="default" 버튼 비활성화 (저장/등록/수정/확인)
|
*
|
||||||
|
* 설계: docs/ycshin-node/필수입력항목_자동검증_설계.md
|
||||||
*/
|
*/
|
||||||
export function useDialogAutoValidation(
|
export function useDialogAutoValidation(contentEl: HTMLElement | null) {
|
||||||
contentRef: RefObject<HTMLElement | null>,
|
|
||||||
) {
|
|
||||||
const mode = useTabStore((s) => s.mode);
|
const mode = useTabStore((s) => s.mode);
|
||||||
const activeRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "user") return;
|
if (mode !== "user") return;
|
||||||
|
|
||||||
const el = contentRef.current;
|
const el = contentEl;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
activeRef.current = true;
|
|
||||||
const injected = new Set<HTMLElement>();
|
const injected = new Set<HTMLElement>();
|
||||||
let isValidating = false;
|
let validationLock = false;
|
||||||
|
const prevEmpty = new Map<Element, boolean>();
|
||||||
|
|
||||||
type InputEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
function findRequiredFields(): Map<TargetEl, string> {
|
||||||
|
const fields = new Map<TargetEl, string>();
|
||||||
function findRequiredFields(): Map<InputEl, string> {
|
|
||||||
const fields = new Map<InputEl, string>();
|
|
||||||
if (!el) return fields;
|
if (!el) return fields;
|
||||||
|
|
||||||
el.querySelectorAll("label").forEach((label) => {
|
el.querySelectorAll("label").forEach((label) => {
|
||||||
|
|
@ -47,45 +60,72 @@ export function useDialogAutoValidation(
|
||||||
);
|
);
|
||||||
if (!hasRequiredMark) return;
|
if (!hasRequiredMark) return;
|
||||||
|
|
||||||
const forId =
|
const forId = label.getAttribute("for") || (label as HTMLLabelElement).htmlFor;
|
||||||
label.getAttribute("for") || (label as HTMLLabelElement).htmlFor;
|
let target: TargetEl | null = null;
|
||||||
let input: Element | null = null;
|
|
||||||
|
|
||||||
if (forId) {
|
if (forId) {
|
||||||
try {
|
try {
|
||||||
input = el!.querySelector(`#${CSS.escape(forId)}`);
|
const found = el!.querySelector(`#${CSS.escape(forId)}`);
|
||||||
|
if (isFormElement(found)) {
|
||||||
|
target = found;
|
||||||
|
} else if (found) {
|
||||||
|
const inner = found.querySelector("input, textarea, select");
|
||||||
|
if (isFormElement(inner) && !isHiddenRadixSelect(inner)) {
|
||||||
|
target = inner;
|
||||||
|
}
|
||||||
|
// 숨겨진 Radix select이거나 폼 요소가 없으면 → 트리거 버튼 탐색
|
||||||
|
if (!target) {
|
||||||
|
const btn = found.querySelector('button[role="combobox"], button[data-slot="select-trigger"]');
|
||||||
|
if (btn instanceof HTMLButtonElement) target = btn;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* invalid id */
|
/* invalid id */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!input) {
|
if (!target) {
|
||||||
const parent =
|
const parent = label.closest('[class*="space-y"]') || label.parentElement;
|
||||||
label.closest('[class*="space-y"]') || label.parentElement;
|
if (parent) {
|
||||||
input = parent?.querySelector("input, textarea, select") || null;
|
const inner = parent.querySelector("input, textarea, select");
|
||||||
|
if (isFormElement(inner) && !isHiddenRadixSelect(inner)) {
|
||||||
|
target = inner;
|
||||||
|
}
|
||||||
|
if (!target) {
|
||||||
|
const btn = parent.querySelector('button[role="combobox"], button[data-slot="select-trigger"]');
|
||||||
|
if (btn instanceof HTMLButtonElement) target = btn;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (target) {
|
||||||
input instanceof HTMLInputElement ||
|
const labelText = label.textContent?.replace(/\*/g, "").trim() || "";
|
||||||
input instanceof HTMLTextAreaElement ||
|
fields.set(target, labelText);
|
||||||
input instanceof HTMLSelectElement
|
|
||||||
) {
|
|
||||||
const labelText =
|
|
||||||
label.textContent?.replace(/\*/g, "").trim() || "";
|
|
||||||
fields.set(input, labelText);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEmpty(input: InputEl): boolean {
|
function isFormElement(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
|
||||||
|
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHiddenRadixSelect(el: Element): boolean {
|
||||||
|
return el instanceof HTMLSelectElement && el.hasAttribute("aria-hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmpty(input: TargetEl): boolean {
|
||||||
|
if (input instanceof HTMLButtonElement) {
|
||||||
|
// Radix Select: data-placeholder 속성이 자식 span에 있으면 미선택 상태
|
||||||
|
return !!input.querySelector("[data-placeholder]");
|
||||||
|
}
|
||||||
return input.value.trim() === "";
|
return input.value.trim() === "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function validate() {
|
function validate() {
|
||||||
if (isValidating) return;
|
if (validationLock) return;
|
||||||
isValidating = true;
|
validationLock = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fields = findRequiredFields();
|
const fields = findRequiredFields();
|
||||||
|
|
@ -94,12 +134,22 @@ export function useDialogAutoValidation(
|
||||||
let hasEmpty = false;
|
let hasEmpty = false;
|
||||||
|
|
||||||
fields.forEach((_label, input) => {
|
fields.forEach((_label, input) => {
|
||||||
if (isEmpty(input)) {
|
const isEmptyNow = isEmpty(input);
|
||||||
hasEmpty = true;
|
const wasPrevEmpty = prevEmpty.get(input);
|
||||||
input.classList.add("border-destructive");
|
|
||||||
|
|
||||||
|
if (isEmptyNow) hasEmpty = true;
|
||||||
|
|
||||||
|
if (wasPrevEmpty === isEmptyNow) return;
|
||||||
|
prevEmpty.set(input, isEmptyNow);
|
||||||
|
|
||||||
|
if (isEmptyNow) {
|
||||||
|
input.setAttribute("data-validation-empty", "true");
|
||||||
const parent = input.parentElement;
|
const parent = input.parentElement;
|
||||||
if (parent && !parent.querySelector(`[${ERROR_ATTR}]`)) {
|
if (parent && !parent.querySelector(`[${ERROR_ATTR}]`)) {
|
||||||
|
const computed = getComputedStyle(parent);
|
||||||
|
if (computed.position === "static") {
|
||||||
|
parent.style.position = "relative";
|
||||||
|
}
|
||||||
const p = document.createElement("p");
|
const p = document.createElement("p");
|
||||||
p.className = "text-xs text-destructive mt-1";
|
p.className = "text-xs text-destructive mt-1";
|
||||||
p.textContent = "필수 입력 항목입니다";
|
p.textContent = "필수 입력 항목입니다";
|
||||||
|
|
@ -108,11 +158,8 @@ export function useDialogAutoValidation(
|
||||||
injected.add(p);
|
injected.add(p);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
input.classList.remove("border-destructive");
|
input.removeAttribute("data-validation-empty");
|
||||||
|
const errorEl = input.parentElement?.querySelector(`[${ERROR_ATTR}]`);
|
||||||
const errorEl = input.parentElement?.querySelector(
|
|
||||||
`[${ERROR_ATTR}]`,
|
|
||||||
);
|
|
||||||
if (errorEl) {
|
if (errorEl) {
|
||||||
injected.delete(errorEl as HTMLElement);
|
injected.delete(errorEl as HTMLElement);
|
||||||
errorEl.remove();
|
errorEl.remove();
|
||||||
|
|
@ -122,28 +169,35 @@ export function useDialogAutoValidation(
|
||||||
|
|
||||||
updateButtons(hasEmpty);
|
updateButtons(hasEmpty);
|
||||||
} finally {
|
} finally {
|
||||||
requestAnimationFrame(() => {
|
setTimeout(() => {
|
||||||
isValidating = false;
|
validationLock = false;
|
||||||
});
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExemptButton(btn: HTMLButtonElement): boolean {
|
||||||
|
if (btn.matches(EXEMPT_BTN_SELECTOR)) return true;
|
||||||
|
const actionType = btn.getAttribute("data-action-type");
|
||||||
|
if (actionType && actionType !== "save" && actionType !== "submit") return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function updateButtons(hasErrors: boolean) {
|
function updateButtons(hasErrors: boolean) {
|
||||||
el!.querySelectorAll<HTMLButtonElement>(ACTION_BTN_SELECTOR).forEach(
|
el!.querySelectorAll<HTMLButtonElement>("button").forEach((btn) => {
|
||||||
(btn) => {
|
if (isExemptButton(btn)) return;
|
||||||
if (hasErrors) {
|
|
||||||
btn.setAttribute(DISABLED_ATTR, "true");
|
if (hasErrors) {
|
||||||
btn.style.opacity = "0.5";
|
btn.setAttribute(DISABLED_ATTR, "true");
|
||||||
btn.style.cursor = "not-allowed";
|
btn.style.opacity = "0.5";
|
||||||
btn.title = "필수 입력 항목을 모두 채워주세요";
|
btn.style.cursor = "not-allowed";
|
||||||
} else if (btn.hasAttribute(DISABLED_ATTR)) {
|
btn.title = "필수 입력 항목을 모두 채워주세요";
|
||||||
btn.removeAttribute(DISABLED_ATTR);
|
} else if (btn.hasAttribute(DISABLED_ATTR)) {
|
||||||
btn.style.opacity = "";
|
btn.removeAttribute(DISABLED_ATTR);
|
||||||
btn.style.cursor = "";
|
btn.style.opacity = "";
|
||||||
btn.title = "";
|
btn.style.cursor = "";
|
||||||
}
|
btn.title = "";
|
||||||
},
|
}
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function blockClick(e: Event) {
|
function blockClick(e: Event) {
|
||||||
|
|
@ -154,24 +208,37 @@ export function useDialogAutoValidation(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
el.addEventListener("input", validate);
|
function handleInteraction() {
|
||||||
el.addEventListener("change", validate);
|
if (!el!.hasAttribute(INTERACTED_ATTR)) {
|
||||||
|
el!.setAttribute(INTERACTED_ATTR, "true");
|
||||||
|
}
|
||||||
|
validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener("input", handleInteraction);
|
||||||
|
el.addEventListener("change", handleInteraction);
|
||||||
el.addEventListener("click", blockClick, true);
|
el.addEventListener("click", blockClick, true);
|
||||||
|
|
||||||
const initTimer = setTimeout(validate, 50);
|
validate();
|
||||||
|
|
||||||
|
const pollId = setInterval(validate, POLL_INTERVAL);
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
if (!isValidating) validate();
|
if (!validationLock) validate();
|
||||||
|
});
|
||||||
|
observer.observe(el, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true,
|
||||||
});
|
});
|
||||||
observer.observe(el, { childList: true, subtree: true });
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
activeRef.current = false;
|
el.removeEventListener("input", handleInteraction);
|
||||||
el.removeEventListener("input", validate);
|
el.removeEventListener("change", handleInteraction);
|
||||||
el.removeEventListener("change", validate);
|
|
||||||
el.removeEventListener("click", blockClick, true);
|
el.removeEventListener("click", blockClick, true);
|
||||||
clearTimeout(initTimer);
|
clearInterval(pollId);
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
|
el.removeAttribute(INTERACTED_ATTR);
|
||||||
|
|
||||||
injected.forEach((p) => p.remove());
|
injected.forEach((p) => p.remove());
|
||||||
injected.clear();
|
injected.clear();
|
||||||
|
|
@ -183,9 +250,9 @@ export function useDialogAutoValidation(
|
||||||
(btn as HTMLElement).title = "";
|
(btn as HTMLElement).title = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
el.querySelectorAll(".border-destructive").forEach((input) => {
|
el.querySelectorAll("[data-validation-empty]").forEach((input) => {
|
||||||
input.classList.remove("border-destructive");
|
input.removeAttribute("data-validation-empty");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, [mode]);
|
}, [mode, contentEl]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1464,6 +1464,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
|
{...(actionType ? { "data-action-type": actionType } : {})}
|
||||||
>
|
>
|
||||||
{buttonContent}
|
{buttonContent}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue