DropdownMenu

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview

DropdownMenu

액션 메뉴 — user menu, 컨텍스트 메뉴 등에 사용. compound API(DropdownMenu.Item, .Separator, .Label)로 구성.
외부 클릭·ESC·항목 클릭 시 자동으로 닫힙니다.

Basic

기본 사용. 항목 클릭 시 자동 close.

Source Code
<DropdownMenu trigger={<Button variant="outline">메뉴 열기</Button>}>
  <DropdownMenu.Item onClick={() => console.log('편집')}>편집</DropdownMenu.Item>
  <DropdownMenu.Item onClick={() => console.log('복제')}>복제</DropdownMenu.Item>
  <DropdownMenu.Separator />
  <DropdownMenu.Item destructive onClick={() => console.log('삭제')}>
    삭제
  </DropdownMenu.Item>
</DropdownMenu>
Live Preview

With label & sections

섹션 라벨로 그룹을 구분.

Source Code
<DropdownMenu trigger={<Button variant="outline">설정</Button>}>
  <DropdownMenu.Label>계정</DropdownMenu.Label>
  <DropdownMenu.Item>프로필</DropdownMenu.Item>
  <DropdownMenu.Item>비밀번호 변경</DropdownMenu.Item>
  <DropdownMenu.Separator />
  <DropdownMenu.Label>워크스페이스</DropdownMenu.Label>
  <DropdownMenu.Item>멤버 초대</DropdownMenu.Item>
  <DropdownMenu.Item>요금제 변경</DropdownMenu.Item>
  <DropdownMenu.Separator />
  <DropdownMenu.Item destructive>로그아웃</DropdownMenu.Item>
</DropdownMenu>
Live Preview

Placement

bottom-end로 우측 정렬 — 페이지 우측 모서리의 user menu에 자주 사용.

Source Code
<DropdownMenu trigger={<Avatar name="John Doe" />} placement="bottom-end">
  <DropdownMenu.Item>내 프로필</DropdownMenu.Item>
  <DropdownMenu.Item>설정</DropdownMenu.Item>
  <DropdownMenu.Separator />
  <DropdownMenu.Item destructive>로그아웃</DropdownMenu.Item>
</DropdownMenu>
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;