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:
2026-03-03 14:54:41 +09:00
parent dca89a698f
commit eb2bd8f10f
7 changed files with 236 additions and 106 deletions

View File

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

View File

@@ -1286,6 +1286,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const externalLabelComponent = needsExternalLabel ? (
<label
htmlFor={component.id}
className="text-sm font-medium leading-none"
style={{
fontSize: style?.labelFontSize || "14px",
@@ -1336,6 +1337,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
isHorizLabel ? (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
htmlFor={component.id}
className="text-sm font-medium leading-none"
style={{
position: "absolute",

View File

@@ -83,17 +83,17 @@ const DialogContent = React.forwardRef<
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
const scoped = !!container;
// 모달 자동 검증용 내부 ref
const internalRef = React.useRef<HTMLDivElement>(null);
// state 기반 ref: DialogPrimitive.Content 마운트/언마운트 시 useEffect 재실행 보장
const [contentNode, setContentNode] = React.useState<HTMLDivElement | null>(null);
const mergedRef = React.useCallback(
(node: HTMLDivElement | null) => {
internalRef.current = node;
setContentNode(node);
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
},
[ref],
);
useDialogAutoValidation(internalRef);
useDialogAutoValidation(contentNode);
const handleInteractOutside = React.useCallback(
(e: any) => {
@@ -152,7 +152,7 @@ const DialogContent = React.forwardRef<
{...props}
>
{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" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@@ -167,7 +167,10 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
)}
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
? multiple
? `${selectedLabels.length}개 선택됨`

View File

@@ -1,11 +1,28 @@
"use client";
import { useEffect, useRef, type RefObject } from "react";
import { useEffect } 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"]';
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" (사용자 모드)
* - 필수 필드(label 내 <span>*</span>)가 1개 이상 존재
*
* 동작:
* - Label 내부 <span> 안의 * 문자로 필수 필드 자동 탐지
* - 빈 필수 필드에 빨간 테두리 + 에러 메시지 주입
* - data-variant="default" 버튼 비활성화 (저장/등록/수정/확인)
* 지원 요소:
* - input, textarea, select (네이티브 폼 요소)
* - button[role="combobox"] (Radix Select / V2Select 드롭다운)
*
* 설계: docs/ycshin-node/필수입력항목_자동검증_설계.md
*/
export function useDialogAutoValidation(
contentRef: RefObject<HTMLElement | null>,
) {
export function useDialogAutoValidation(contentEl: HTMLElement | null) {
const mode = useTabStore((s) => s.mode);
const activeRef = useRef(false);
useEffect(() => {
if (mode !== "user") return;
const el = contentRef.current;
const el = contentEl;
if (!el) return;
activeRef.current = true;
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<InputEl, string> {
const fields = new Map<InputEl, string>();
function findRequiredFields(): Map<TargetEl, string> {
const fields = new Map<TargetEl, string>();
if (!el) return fields;
el.querySelectorAll("label").forEach((label) => {
@@ -47,45 +60,72 @@ export function useDialogAutoValidation(
);
if (!hasRequiredMark) return;
const forId =
label.getAttribute("for") || (label as HTMLLabelElement).htmlFor;
let input: Element | null = null;
const forId = label.getAttribute("for") || (label as HTMLLabelElement).htmlFor;
let target: TargetEl | null = null;
if (forId) {
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 {
/* invalid id */
}
}
if (!input) {
const parent =
label.closest('[class*="space-y"]') || label.parentElement;
input = parent?.querySelector("input, textarea, select") || null;
if (!target) {
const parent = label.closest('[class*="space-y"]') || label.parentElement;
if (parent) {
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 (
input instanceof HTMLInputElement ||
input instanceof HTMLTextAreaElement ||
input instanceof HTMLSelectElement
) {
const labelText =
label.textContent?.replace(/\*/g, "").trim() || "";
fields.set(input, labelText);
if (target) {
const labelText = label.textContent?.replace(/\*/g, "").trim() || "";
fields.set(target, labelText);
}
});
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() === "";
}
function validate() {
if (isValidating) return;
isValidating = true;
if (validationLock) return;
validationLock = true;
try {
const fields = findRequiredFields();
@@ -94,12 +134,22 @@ export function useDialogAutoValidation(
let hasEmpty = false;
fields.forEach((_label, input) => {
if (isEmpty(input)) {
hasEmpty = true;
input.classList.add("border-destructive");
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 = "필수 입력 항목입니다";
@@ -108,11 +158,8 @@ export function useDialogAutoValidation(
injected.add(p);
}
} else {
input.classList.remove("border-destructive");
const errorEl = input.parentElement?.querySelector(
`[${ERROR_ATTR}]`,
);
input.removeAttribute("data-validation-empty");
const errorEl = input.parentElement?.querySelector(`[${ERROR_ATTR}]`);
if (errorEl) {
injected.delete(errorEl as HTMLElement);
errorEl.remove();
@@ -122,28 +169,35 @@ export function useDialogAutoValidation(
updateButtons(hasEmpty);
} finally {
requestAnimationFrame(() => {
isValidating = false;
});
setTimeout(() => {
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) {
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 = "";
}
},
);
el!.querySelectorAll<HTMLButtonElement>("button").forEach((btn) => {
if (isExemptButton(btn)) return;
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) {
@@ -154,24 +208,37 @@ export function useDialogAutoValidation(
}
}
el.addEventListener("input", validate);
el.addEventListener("change", validate);
function handleInteraction() {
if (!el!.hasAttribute(INTERACTED_ATTR)) {
el!.setAttribute(INTERACTED_ATTR, "true");
}
validate();
}
el.addEventListener("input", handleInteraction);
el.addEventListener("change", handleInteraction);
el.addEventListener("click", blockClick, true);
const initTimer = setTimeout(validate, 50);
validate();
const pollId = setInterval(validate, POLL_INTERVAL);
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 () => {
activeRef.current = false;
el.removeEventListener("input", validate);
el.removeEventListener("change", validate);
el.removeEventListener("input", handleInteraction);
el.removeEventListener("change", handleInteraction);
el.removeEventListener("click", blockClick, true);
clearTimeout(initTimer);
clearInterval(pollId);
observer.disconnect();
el.removeAttribute(INTERACTED_ATTR);
injected.forEach((p) => p.remove());
injected.clear();
@@ -183,9 +250,9 @@ export function useDialogAutoValidation(
(btn as HTMLElement).title = "";
});
el.querySelectorAll(".border-destructive").forEach((input) => {
input.classList.remove("border-destructive");
el.querySelectorAll("[data-validation-empty]").forEach((input) => {
input.removeAttribute("data-validation-empty");
});
};
}, [mode]);
}, [mode, contentEl]);
}

View File

@@ -1464,6 +1464,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...(actionType ? { "data-action-type": actionType } : {})}
>
{buttonContent}
</button>