diff --git a/frontend/app/globals.css b/frontend/app/globals.css index b54c5c32..4c2c4759 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -441,4 +441,21 @@ select { 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 ===== */ diff --git a/frontend/components/layout/TabBar.tsx b/frontend/components/layout/TabBar.tsx index 15c996c5..8a850d20 100644 --- a/frontend/components/layout/TabBar.tsx +++ b/frontend/components/layout/TabBar.tsx @@ -20,7 +20,6 @@ const DRAG_THRESHOLD = 5; const SETTLE_MS = 70; const DROP_SETTLE_MS = 180; const BAR_PAD_X = 8; -const ACTIVE_TAB_BORDER_OPACITY = 0.3; interface DragState { tabId: string; @@ -502,7 +501,7 @@ export function TabBar() { touchAction: "none", ...animStyle, ...(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} > diff --git a/frontend/lib/hooks/useDialogAutoValidation.ts b/frontend/lib/hooks/useDialogAutoValidation.ts index 5e710628..eefa9342 100644 --- a/frontend/lib/hooks/useDialogAutoValidation.ts +++ b/frontend/lib/hooks/useDialogAutoValidation.ts @@ -6,6 +6,7 @@ import { toast } from "sonner"; const HIGHLIGHT_ATTR = "data-validation-highlight"; const ERROR_ATTR = "data-validation-error"; +const MSG_WRAPPER_CLASS = "validation-error-msg-wrapper"; type TargetEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement; @@ -121,11 +122,32 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) { function markError(input: TargetEl) { input.setAttribute(ERROR_ATTR, "true"); errorFields.add(input); + showErrorMsg(input); } function clearError(input: TargetEl) { input.removeAttribute(ERROR_ATTR); 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) { @@ -200,6 +222,7 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) { el.querySelectorAll(`[${HIGHLIGHT_ATTR}]`).forEach((node) => node.removeAttribute(HIGHLIGHT_ATTR)); el.querySelectorAll(`[${ERROR_ATTR}]`).forEach((node) => node.removeAttribute(ERROR_ATTR)); + el.querySelectorAll(`.${MSG_WRAPPER_CLASS}`).forEach((node) => node.remove()); }; }, [mode, contentEl]); }