- Introduced new documents detailing the implementation of visual separation for three-level category dropdowns. - Updated the `flattenTree` function in both `V2Select.tsx` and `UnifiedSelect.tsx` to use Non-Breaking Space (`\u00A0`) for indentation, ensuring proper visual hierarchy. - Included a checklist to track the implementation progress and verification of the changes. - Documented the rationale behind the changes, including the issues with HTML whitespace collapsing and the decisions made to enhance user experience. These updates aim to improve the clarity and usability of the category selection interface in the application.
245 lines
8.3 KiB
TypeScript
245 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
import { useTabStore } from "@/stores/tabStore";
|
|
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;
|
|
|
|
/**
|
|
* 모달 필수 입력 검증 훅 (클릭 인터셉트 방식)
|
|
*
|
|
* 저장/수정/확인 버튼 클릭 시 빈 필수 필드가 있으면:
|
|
* 1. 클릭 이벤트 차단
|
|
* 2. 첫 번째 빈 필드로 포커스 이동 + 하이라이트
|
|
* 3. 빈 필드에 빨간 테두리 유지 (값 입력 시 해제)
|
|
* 4. 토스트 알림 표시
|
|
*
|
|
* 설계: docs/ycshin-node/필수입력항목_자동검증_설계.md
|
|
*/
|
|
export function useDialogAutoValidation(contentEl: HTMLElement | null) {
|
|
const mode = useTabStore((s) => s.mode);
|
|
|
|
useEffect(() => {
|
|
if (mode !== "user") return;
|
|
|
|
const el = contentEl;
|
|
if (!el) return;
|
|
|
|
const errorFields = new Set<TargetEl>();
|
|
let activated = false; // 첫 저장 시도 이후 true
|
|
|
|
function findRequiredFields(): Map<TargetEl, string> {
|
|
const fields = new Map<TargetEl, 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 target: TargetEl | null = null;
|
|
|
|
if (forId) {
|
|
try {
|
|
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 (!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 (target) {
|
|
const labelText = label.textContent?.replace(/\*/g, "").trim() || "";
|
|
fields.set(target, labelText);
|
|
}
|
|
});
|
|
|
|
return fields;
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
// 복합 입력 필드(채번 세그먼트 등)의 시각적 테두리 컨테이너 탐지
|
|
// input 자체에 border가 없고 부모가 border를 가진 경우 부모를 반환
|
|
function findBorderContainer(input: TargetEl): HTMLElement {
|
|
const parent = input.parentElement;
|
|
if (parent && parent.classList.contains("border")) {
|
|
return parent;
|
|
}
|
|
return input;
|
|
}
|
|
|
|
function isEmpty(input: TargetEl): boolean {
|
|
if (input instanceof HTMLButtonElement) {
|
|
// Radix Select: data-placeholder 속성이 자식 span에 있으면 미선택 상태
|
|
return !!input.querySelector("[data-placeholder]");
|
|
}
|
|
return input.value.trim() === "";
|
|
}
|
|
|
|
function isSaveButton(target: HTMLElement): boolean {
|
|
const btn = target.closest("button");
|
|
if (!btn) return false;
|
|
|
|
const actionType = btn.getAttribute("data-action-type");
|
|
if (actionType === "save" || actionType === "submit") return true;
|
|
|
|
const variant = btn.getAttribute("data-variant");
|
|
if (variant === "default") return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
function markError(input: TargetEl) {
|
|
const container = findBorderContainer(input);
|
|
container.setAttribute(ERROR_ATTR, "true");
|
|
errorFields.add(input);
|
|
showErrorMsg(input);
|
|
}
|
|
|
|
function clearError(input: TargetEl) {
|
|
const container = findBorderContainer(input);
|
|
container.removeAttribute(ERROR_ATTR);
|
|
errorFields.delete(input);
|
|
removeErrorMsg(input);
|
|
}
|
|
|
|
// 빈 필수 필드 아래에 경고 문구 삽입 (레이아웃 영향 없는 zero-height wrapper)
|
|
// 복합 입력(채번 세그먼트 등)은 border 컨테이너 바깥에 삽입
|
|
function showErrorMsg(input: TargetEl) {
|
|
const container = findBorderContainer(input);
|
|
if (container.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);
|
|
|
|
container.insertAdjacentElement("afterend", wrapper);
|
|
}
|
|
|
|
function removeErrorMsg(input: TargetEl) {
|
|
const container = findBorderContainer(input);
|
|
const wrapper = container.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`);
|
|
if (wrapper) wrapper.remove();
|
|
}
|
|
|
|
function highlightField(input: TargetEl) {
|
|
const container = findBorderContainer(input);
|
|
container.setAttribute(HIGHLIGHT_ATTR, "true");
|
|
container.addEventListener("animationend", () => container.removeAttribute(HIGHLIGHT_ATTR), { once: true });
|
|
|
|
if (input instanceof HTMLButtonElement) {
|
|
input.click();
|
|
} else {
|
|
input.focus();
|
|
}
|
|
}
|
|
|
|
// 첫 저장 시도 이후: 빈 필드 → 에러 유지/재적용, 값 있으면 해제
|
|
function syncErrors() {
|
|
if (!activated) return;
|
|
const fields = findRequiredFields();
|
|
for (const [input] of fields) {
|
|
if (isEmpty(input)) {
|
|
markError(input);
|
|
} else {
|
|
clearError(input);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleClick(e: Event) {
|
|
const target = e.target as HTMLElement;
|
|
if (!isSaveButton(target)) return;
|
|
|
|
const fields = findRequiredFields();
|
|
if (fields.size === 0) return;
|
|
|
|
let firstEmpty: TargetEl | null = null;
|
|
let firstEmptyLabel = "";
|
|
|
|
for (const [input, label] of fields) {
|
|
if (isEmpty(input)) {
|
|
markError(input);
|
|
if (!firstEmpty) {
|
|
firstEmpty = input;
|
|
firstEmptyLabel = label;
|
|
}
|
|
} else {
|
|
clearError(input);
|
|
}
|
|
}
|
|
|
|
if (!firstEmpty) return;
|
|
|
|
activated = true;
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
highlightField(firstEmpty);
|
|
toast.error(`${firstEmptyLabel} 항목을 입력해주세요`);
|
|
}
|
|
|
|
// V2Select는 input/change 이벤트가 없으므로 DOM 변경 감지로 에러 동기화
|
|
const observer = new MutationObserver(syncErrors);
|
|
observer.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-placeholder"] });
|
|
|
|
el.addEventListener("click", handleClick, true);
|
|
el.addEventListener("input", syncErrors);
|
|
el.addEventListener("change", syncErrors);
|
|
|
|
return () => {
|
|
el.removeEventListener("click", handleClick, true);
|
|
el.removeEventListener("input", syncErrors);
|
|
el.removeEventListener("change", syncErrors);
|
|
observer.disconnect();
|
|
|
|
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]);
|
|
}
|