feat: Implement validation error message display for required fields

- Added a new CSS class for displaying validation error messages below required input fields without affecting layout.
- Enhanced the `useDialogAutoValidation` hook to insert error messages dynamically when required fields are empty.
- Implemented logic to remove error messages when the input is cleared, improving user feedback during form validation.

Made-with: Cursor
This commit is contained in:
syc0123 2026-03-04 09:23:09 +09:00
parent 35dfe5bd79
commit cfd49020a0
3 changed files with 41 additions and 2 deletions

View File

@ -441,4 +441,21 @@ select {
border-color: hsl(var(--destructive)) !important; border-color: hsl(var(--destructive)) !important;
} }
/* 필수 입력 경고 문구 (입력 필드 아래, 레이아웃 영향 없음) */
.validation-error-msg-wrapper {
height: 0;
overflow: visible;
position: relative;
}
.validation-error-msg-wrapper > p {
position: absolute;
top: 1px;
left: 0;
font-size: 11px;
color: hsl(var(--destructive));
white-space: nowrap;
pointer-events: none;
}
/* ===== End of Global Styles ===== */ /* ===== End of Global Styles ===== */

View File

@ -20,7 +20,6 @@ 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;
@ -502,7 +501,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})` } : {}), ...(isActive ? { boxShadow: "0 -1px 28px rgba(0,0,0,0.9)" } : {}),
}} }}
title={tab.title} title={tab.title}
> >

View File

@ -6,6 +6,7 @@ import { toast } from "sonner";
const HIGHLIGHT_ATTR = "data-validation-highlight"; const HIGHLIGHT_ATTR = "data-validation-highlight";
const ERROR_ATTR = "data-validation-error"; const ERROR_ATTR = "data-validation-error";
const MSG_WRAPPER_CLASS = "validation-error-msg-wrapper";
type TargetEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement; type TargetEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement;
@ -121,11 +122,32 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
function markError(input: TargetEl) { function markError(input: TargetEl) {
input.setAttribute(ERROR_ATTR, "true"); input.setAttribute(ERROR_ATTR, "true");
errorFields.add(input); errorFields.add(input);
showErrorMsg(input);
} }
function clearError(input: TargetEl) { function clearError(input: TargetEl) {
input.removeAttribute(ERROR_ATTR); input.removeAttribute(ERROR_ATTR);
errorFields.delete(input); errorFields.delete(input);
removeErrorMsg(input);
}
// 빈 필수 필드 아래에 경고 문구 삽입 (레이아웃 영향 없는 zero-height wrapper)
function showErrorMsg(input: TargetEl) {
if (input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return;
const wrapper = document.createElement("div");
wrapper.className = MSG_WRAPPER_CLASS;
const msg = document.createElement("p");
msg.textContent = "필수 입력 항목입니다";
wrapper.appendChild(msg);
input.insertAdjacentElement("afterend", wrapper);
}
function removeErrorMsg(input: TargetEl) {
const wrapper = input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`);
if (wrapper) wrapper.remove();
} }
function highlightField(input: TargetEl) { function highlightField(input: TargetEl) {
@ -200,6 +222,7 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
el.querySelectorAll(`[${HIGHLIGHT_ATTR}]`).forEach((node) => node.removeAttribute(HIGHLIGHT_ATTR)); el.querySelectorAll(`[${HIGHLIGHT_ATTR}]`).forEach((node) => node.removeAttribute(HIGHLIGHT_ATTR));
el.querySelectorAll(`[${ERROR_ATTR}]`).forEach((node) => node.removeAttribute(ERROR_ATTR)); el.querySelectorAll(`[${ERROR_ATTR}]`).forEach((node) => node.removeAttribute(ERROR_ATTR));
el.querySelectorAll(`.${MSG_WRAPPER_CLASS}`).forEach((node) => node.remove());
}; };
}, [mode, contentEl]); }, [mode, contentEl]);
} }