DropdownMenu
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
계정
DropdownMenu
액션 메뉴 — user menu, 컨텍스트 메뉴 등에 사용. compound API(DropdownMenu.Item, .Separator, .Label)로 구성.
외부 클릭·ESC·항목 클릭 시 자동으로 닫힙니다.
Basic
기본 사용. 항목 클릭 시 자동 close.
Source Code
Live Preview
With label & sections
섹션 라벨로 그룹을 구분.
Source Code
Live Preview
Placement
bottom-end로 우측 정렬 — 페이지 우측 모서리의 user menu에 자주 사용.
Source Code
Live Preview
Implementation
제작 코드
이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.
DropdownMenutypescript
'use client';
/**
* DropdownMenu — 액션 메뉴 (user menu, 컨텍스트 메뉴).
*
* - compound API: <DropdownMenu trigger={...}><DropdownMenu.Item /><DropdownMenu.Separator /></DropdownMenu>
* - click outside / ESC 닫기 / 항목 클릭 시 자동 닫기
* - 단순 absolute 포지셔닝 — 뷰포트 collision 회피 미지원 (Floating UI 미도입)
*/
import {
cloneElement,
createContext,
forwardRef,
isValidElement,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ButtonHTMLAttributes,
type HTMLAttributes,
type ReactElement,
type ReactNode,
} from 'react';
import { cn } from '@/lib/cn';
export type DropdownMenuPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
export interface DropdownMenuProps {
trigger: ReactNode;
children: ReactNode;
placement?: DropdownMenuPlacement;
/** 메뉴 패널 추가 클래스. */
panelClassName?: string;
/** 컨테이너 추가 클래스. */
className?: string;
/** 외부에서 controlled 사용. 미지정 시 내부 state. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const placementStyles: Record<DropdownMenuPlacement, string> = {
'bottom-start': 'top-full left-0 mt-2',
'bottom-end': 'top-full right-0 mt-2',
'top-start': 'bottom-full left-0 mb-2',
'top-end': 'bottom-full right-0 mb-2',
};
interface DropdownContextValue {
close: () => void;
}
const DropdownContext = createContext<DropdownContextValue | null>(null);
function useDropdownContext() {
const ctx = useContext(DropdownContext);
if (!ctx) {
throw new Error('DropdownMenu.Item / Separator / Label must be used inside <DropdownMenu>.');
}
return ctx;
}
function DropdownMenuRoot({
trigger,
children,
placement = 'bottom-start',
panelClassName,
className,
open: controlledOpen,
onOpenChange,
}: DropdownMenuProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!isControlled) setUncontrolledOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
const close = useCallback(() => setOpen(false), [setOpen]);
const toggle = useCallback(() => setOpen(!open), [open, setOpen]);
const containerRef = useRef<HTMLDivElement | null>(null);
// Click outside
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
const node = containerRef.current;
if (!node) return;
if (e.target instanceof Node && !node.contains(e.target)) {
close();
}
};
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleEsc);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleEsc);
};
}, [open, close]);
// Trigger 이벤트 바인딩 (cloneElement)
const triggerNode = isValidElement(trigger)
? cloneElement(
trigger as ReactElement<{
onClick?: (e: React.MouseEvent) => void;
'aria-expanded'?: boolean;
'aria-haspopup'?: 'menu';
}>,
{
onClick: (e: React.MouseEvent) => {
const t = trigger as ReactElement<{ onClick?: (e: React.MouseEvent) => void }>;
t.props.onClick?.(e);
toggle();
},
'aria-expanded': open,
'aria-haspopup': 'menu',
},
)
: trigger;
return (
<DropdownContext.Provider value={{ close }}>
<div ref={containerRef} className={cn('relative inline-block', className)}>
{triggerNode}
{open && (
<div
role="menu"
className={cn(
'absolute z-50 min-w-[10rem] origin-top rounded-2xl border border-slate-200 bg-white p-1.5 shadow-xl shadow-slate-900/10',
'animate-in fade-in zoom-in-95 duration-150',
placementStyles[placement],
panelClassName,
)}
>
{children}
</div>
)}
</div>
</DropdownContext.Provider>
);
}
/* ============================================
Item — 클릭 시 onClick 후 자동으로 닫힘
============================================ */
export interface DropdownMenuItemProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
/** 위험 액션 (삭제 등) — rose 색조로 강조 */
destructive?: boolean;
/** 좌측 아이콘 슬롯 */
icon?: ReactNode;
}
const DropdownMenuItem = forwardRef<HTMLButtonElement, DropdownMenuItemProps>(
({ onClick, destructive, icon, className, children, ...rest }, ref) => {
const { close } = useDropdownContext();
return (
<button
ref={ref}
type="button"
role="menuitem"
onClick={(e) => {
onClick?.(e);
if (!e.defaultPrevented) close();
}}
className={cn(
'flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50',
destructive
? 'text-rose-600 hover:bg-rose-50 focus:bg-rose-50 focus:outline-none'
: 'text-slate-700 hover:bg-slate-100 focus:bg-slate-100 focus:outline-none',
className,
)}
{...rest}
>
{icon && <span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">{icon}</span>}
<span className="flex-1 truncate">{children}</span>
</button>
);
},
);
DropdownMenuItem.displayName = 'DropdownMenu.Item';
/* ============================================
Separator
============================================ */
const DropdownMenuSeparator = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...rest }, ref) => (
<div
ref={ref}
role="separator"
aria-orientation="horizontal"
className={cn('my-1 h-px bg-slate-100', className)}
{...rest}
/>
),
);
DropdownMenuSeparator.displayName = 'DropdownMenu.Separator';
/* ============================================
Label — 섹션 라벨 (클릭 불가)
============================================ */
const DropdownMenuLabel = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, children, ...rest }, ref) => (
<div
ref={ref}
className={cn(
'px-3 pt-2 pb-1 text-[10px] font-bold uppercase tracking-[0.18em] text-slate-400',
className,
)}
{...rest}
>
{children}
</div>
),
);
DropdownMenuLabel.displayName = 'DropdownMenu.Label';
/* ============================================
Compound export
============================================ */
type DropdownMenuType = typeof DropdownMenuRoot & {
Item: typeof DropdownMenuItem;
Separator: typeof DropdownMenuSeparator;
Label: typeof DropdownMenuLabel;
};
export const DropdownMenu = DropdownMenuRoot as DropdownMenuType;
DropdownMenu.Item = DropdownMenuItem;
DropdownMenu.Separator = DropdownMenuSeparator;
DropdownMenu.Label = DropdownMenuLabel;