/* ============================================================================
* 서명전에 — TDS Menu 컴포넌트 (드롭다운)
* · 6 개 서브:
* Menu.Dropdown : 드롭다운 패널 (자식들을 감싸는 컨테이너)
* Menu.Header : 패널 안 작은 라벨
* Menu.DropdownItem : left / right 슬롯이 있는 메뉴 항목
* Menu.DropdownIcon : 슬롯에 들어가는 아이콘 헬퍼 (URL or IOSIcon)
* Menu.DropdownCheckItem : DropdownItem + 자동 체크박스
* Menu.Trigger : 자식을 감싸 클릭하면 드롭다운을 띄워 줘요.
*
* · MenuContext 로 DropdownItem 의 onClick 후 자동 close. CheckItem 은 닫지 않음
* (스펙대로 — 다중 선택을 위해).
* · Trigger 는 controlled (open + onOpenChange) / uncontrolled (defaultOpen).
* · 외부 클릭 + ESC 로 닫혀요. document mousedown / keydown 리스너.
* · placement 12 종 — CSS 에서 absolute 위치를 결정.
* · 패널만 standalone 으로도 쓸 수 있어요 (예: 직접 위치 잡고 싶을 때).
* ========================================================================== */
const __MENU_PLACEMENTS = {
"top-start": 1, "top-center": 1, "top-end": 1,
"bottom-start": 1, "bottom-center": 1, "bottom-end": 1,
"left-start": 1, "left-center": 1, "left-end": 1,
"right-start": 1, "right-center": 1, "right-end": 1,
};
// MenuContext: DropdownItem 의 자동 close 를 위해 close 핸들러를 내려보내요.
const __MenuContext = React.createContext({ close: null });
// ---------- Dropdown (panel) ----------
function MenuDropdown({ children, className = "", style, id, ...rest }) {
const cls = ["tds-menu-dropdown", className].filter(Boolean).join(" ");
return (
{children}
);
}
// ---------- Header ----------
function MenuHeader({ children, className = "", style, ...rest }) {
const cls = ["tds-menu-dropdown__header", className].filter(Boolean).join(" ");
return (
{children}
);
}
// ---------- DropdownItem ----------
function MenuDropdownItem({
children,
left,
right,
onClick,
disabled = false,
className = "",
style,
closeOnClick = true, // 내부 옵션 — DropdownCheckItem 에서 false 로 넘김
...rest
}) {
const ctx = React.useContext(__MenuContext);
const handleClick = function (e) {
if (disabled) return;
if (typeof onClick === "function") onClick(e);
if (closeOnClick && ctx && typeof ctx.close === "function") ctx.close();
};
const cls = ["tds-menu-dropdown__item", className].filter(Boolean).join(" ");
return (
);
}
// ---------- DropdownIcon ----------
// 슬롯에 들어가는 아이콘 헬퍼.
// · name 이 URL/경로면
로 렌더 (mono 면 mask 트릭으로 currentColor 칠하기)
// · 그 외엔 IOSIcon 으로 위임 (있을 때만)
function MenuDropdownIcon({ name, mono, size, className = "", style, ...rest }) {
if (!name) return null;
const isUrl =
/^(https?:|\/|\.{1,2}\/)/i.test(name) ||
/\.(svg|png|jpe?g|gif|webp)(?:\?|#|$)/i.test(name);
// mono 추론: 파일명에 -mono 들어 있으면 자동 mono 로 봐요.
const inferredMono =
typeof mono === "boolean"
? mono
: isUrl && /-mono(?:\.[^/?#]+)?(?:\?[^#]*)?(?:#.*)?$/i.test(name);
const cls = [
"tds-menu-dropdown__icon",
inferredMono ? "tds-menu-dropdown__icon--mono" : "",
className,
].filter(Boolean).join(" ");
const px = typeof size === "number" ? `${size}px` : (size || undefined);
const mergedStyle = px ? Object.assign({ width: px, height: px }, style) : style;
if (isUrl) {
if (inferredMono) {
// mask + currentColor 트릭
const maskStyle = Object.assign(
{
WebkitMaskImage: `url("${name}")`,
maskImage: `url("${name}")`,
},
mergedStyle
);
return (
{/* 접근성 text 이미지 흔적 — 시각적으론 안보이게 */}
);
}
return (
);
}
// IOSIcon 위임
if (window.IOSIcon) {
return (
);
}
// 폴백 — 빈 박스
return ;
}
// ---------- DropdownCheckItem ----------
function MenuDropdownCheckItem({
children,
left,
checked = false,
onCheckedChange,
onClick,
disabled = false,
className = "",
style,
...rest
}) {
const handleClick = function (e) {
if (disabled) return;
if (typeof onClick === "function") onClick(e);
if (typeof onCheckedChange === "function") onCheckedChange(!checked);
};
// checked 시 클래스 토글로 시각 상태 전환
const cls = [
checked ? "tds-menu-dropdown__item--checked" : "",
className,
].filter(Boolean).join(" ");
// 우측 슬롯에 체크박스 자동 삽입
const checkbox = (
);
return (
{children}
);
}
// ---------- Trigger (controlled / uncontrolled wrapper) ----------
function MenuTrigger({
children,
dropdown,
placement = "bottom-start",
open, // controlled
defaultOpen = false, // uncontrolled
onOpenChange,
closeOnOutsideClick = true,
closeOnEscape = true,
className = "",
style,
id,
...rest
}) {
const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = React.useState(!!defaultOpen);
const isOpen = isControlled ? open : internalOpen;
// placement 검증
if (!__MENU_PLACEMENTS[placement]) {
if (window.console && console.warn) {
console.warn(
`[Menu.Trigger] \`placement\` 가 잘못됐어요: "${placement}". ` +
`top/bottom/left/right × start/center/end 조합만 가능해요.`
);
}
}
const safePlacement = __MENU_PLACEMENTS[placement] ? placement : "bottom-start";
const setOpen = function (next) {
if (typeof onOpenChange === "function") onOpenChange(next);
if (!isControlled) setInternalOpen(next);
};
const close = React.useCallback(function () { setOpen(false); }, [isControlled, onOpenChange]);
const toggle = function () { setOpen(!isOpen); };
const rootRef = React.useRef(null);
// 외부 클릭 감지 — 패널/트리거 밖 클릭이면 close
React.useEffect(function () {
if (!isOpen || !closeOnOutsideClick) return undefined;
const onDocDown = function (e) {
const root = rootRef.current;
if (!root) return;
if (root.contains(e.target)) return; // 안쪽 클릭이면 무시
close();
};
document.addEventListener("mousedown", onDocDown);
document.addEventListener("touchstart", onDocDown, { passive: true });
return function () {
document.removeEventListener("mousedown", onDocDown);
document.removeEventListener("touchstart", onDocDown);
};
}, [isOpen, closeOnOutsideClick, close]);
// ESC 닫기
React.useEffect(function () {
if (!isOpen || !closeOnEscape) return undefined;
const onKey = function (e) {
if (e.key === "Escape" || e.key === "Esc") {
e.stopPropagation();
close();
}
};
document.addEventListener("keydown", onKey);
return function () { document.removeEventListener("keydown", onKey); };
}, [isOpen, closeOnEscape, close]);
// children (트리거)에 onClick wrap — 기존 onClick 도 호출
let triggerChild = children;
if (React.isValidElement(children)) {
const childOnClick = children.props.onClick;
triggerChild = React.cloneElement(children, {
onClick: function (e) {
if (typeof childOnClick === "function") childOnClick(e);
if (e && e.defaultPrevented) return;
toggle();
},
"aria-haspopup": "menu",
"aria-expanded": isOpen ? "true" : "false",
});
}
const cls = ["tds-menu-trigger", className].filter(Boolean).join(" ");
return (
<__MenuContext.Provider value={{ close }}>
{triggerChild}
{isOpen && (
{dropdown}
)}
);
}
// ---------- Menu namespace (서브들 부착) ----------
const Menu = {
Dropdown: MenuDropdown,
Header: MenuHeader,
DropdownItem: MenuDropdownItem,
DropdownIcon: MenuDropdownIcon,
DropdownCheckItem: MenuDropdownCheckItem,
Trigger: MenuTrigger,
};
window.Menu = Menu;
window.MenuDropdown = MenuDropdown;
window.MenuHeader = MenuHeader;
window.MenuDropdownItem = MenuDropdownItem;
window.MenuDropdownIcon = MenuDropdownIcon;
window.MenuDropdownCheckItem = MenuDropdownCheckItem;
window.MenuTrigger = MenuTrigger;
Object.assign(window, {
Menu,
MenuDropdown,
MenuHeader,
MenuDropdownItem,
MenuDropdownIcon,
MenuDropdownCheckItem,
MenuTrigger,
});