Popover

Interactive UI Explorer

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

Live Preview

Popover

클릭으로 열리는 anchored 패널. Tooltip(hover) · DropdownMenu(메뉴 항목)와 의미 분리 — 임의의 콘텐츠를 anchored 영역에 담을 때 사용합니다.

Basic

외부 클릭 / ESC 자동 닫기. placement는 8방향 지원 (default: bottom-start).

Source Code
<Popover trigger={<Button variant="outline">알림 설정</Button>}>
  <div className="space-y-3">
    <h4 className="text-sm font-bold text-slate-900">알림 설정</h4>
    <Switch label="이메일 알림" checked={true} onChange={...} />
    <Switch label="푸시 알림" checked={false} onChange={...} />
  </div>
</Popover>
Live Preview

Placements

bottom / top / left / right + start/end 변형.

bottom-start
<Popover placement="bottom-start" trigger={...}>...</Popover>
Live Preview
bottom-end
<Popover placement="bottom-end" trigger={...}>...</Popover>
Live Preview
top-start
<Popover placement="top-start" trigger={...}>...</Popover>
Live Preview
top-end
<Popover placement="top-end" trigger={...}>...</Popover>
Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Popovertypescript
'use client';

/**
 * Popover — 클릭으로 열리는 anchored 패널.
 *
 * Tooltip(hover)·DropdownMenu(메뉴 항목)와 의미 분리 — 임의의 콘텐츠를 anchored 패널에 담는다.
 * - placement: top/bottom/left/right + start/end (8방향)
 * - 외부 클릭 / ESC 닫기
 * - 단순 absolute 포지셔닝 — 뷰포트 collision 회피 미지원
 */

import {
  cloneElement,
  forwardRef,
  isValidElement,
  useCallback,
  useEffect,
  useRef,
  useState,
  type HTMLAttributes,
  type ReactElement,
  type ReactNode,
} from 'react';
import { cn } from '@/lib/cn';

export type PopoverPlacement =
  | 'top'
  | 'top-start'
  | 'top-end'
  | 'bottom'
  | 'bottom-start'
  | 'bottom-end'
  | 'left'
  | 'right';

export interface PopoverProps extends Omit<HTMLAttributes<HTMLDivElement>, 'content'> {
  trigger: ReactNode;
  children: ReactNode;
  placement?: PopoverPlacement;
  /** controlled */
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** 패널 추가 클래스 */
  panelClassName?: string;
  /** 외부 클릭 시 닫기 (default: true) */
  closeOnOutside?: boolean;
  /** ESC 닫기 (default: true) */
  closeOnEsc?: boolean;
}

const placementStyles: Record<PopoverPlacement, string> = {
  top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
  'top-start': 'bottom-full left-0 mb-2',
  'top-end': 'bottom-full right-0 mb-2',
  bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
  'bottom-start': 'top-full left-0 mt-2',
  'bottom-end': 'top-full right-0 mt-2',
  left: 'right-full top-1/2 -translate-y-1/2 mr-2',
  right: 'left-full top-1/2 -translate-y-1/2 ml-2',
};

export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
  (
    {
      trigger,
      children,
      placement = 'bottom-start',
      open: controlledOpen,
      onOpenChange,
      panelClassName,
      closeOnOutside = true,
      closeOnEsc = true,
      className,
      ...rest
    },
    forwardedRef,
  ) => {
    const [uncontrolled, setUncontrolled] = useState(false);
    const isControlled = controlledOpen !== undefined;
    const open = isControlled ? controlledOpen : uncontrolled;

    const setOpen = useCallback(
      (next: boolean) => {
        if (!isControlled) setUncontrolled(next);
        onOpenChange?.(next);
      },
      [isControlled, onOpenChange],
    );

    const containerRef = useRef<HTMLDivElement | null>(null);
    const setRefs = useCallback(
      (node: HTMLDivElement | null) => {
        containerRef.current = node;
        if (typeof forwardedRef === 'function') forwardedRef(node);
        else if (forwardedRef) {
          (forwardedRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
        }
      },
      [forwardedRef],
    );

    const close = useCallback(() => setOpen(false), [setOpen]);
    const toggle = useCallback(() => setOpen(!open), [open, setOpen]);

    useEffect(() => {
      if (!open) return;
      const onClick = (e: MouseEvent) => {
        if (!closeOnOutside) return;
        const node = containerRef.current;
        if (!node) return;
        if (e.target instanceof Node && !node.contains(e.target)) close();
      };
      const onKey = (e: KeyboardEvent) => {
        if (e.key === 'Escape' && closeOnEsc) close();
      };
      document.addEventListener('mousedown', onClick);
      document.addEventListener('keydown', onKey);
      return () => {
        document.removeEventListener('mousedown', onClick);
        document.removeEventListener('keydown', onKey);
      };
    }, [open, closeOnOutside, closeOnEsc, close]);

    const triggerNode = isValidElement(trigger)
      ? cloneElement(
          trigger as ReactElement<{
            onClick?: (e: React.MouseEvent) => void;
            'aria-expanded'?: boolean;
            'aria-haspopup'?: 'dialog';
          }>,
          {
            onClick: (e: React.MouseEvent) => {
              const t = trigger as ReactElement<{ onClick?: (e: React.MouseEvent) => void }>;
              t.props.onClick?.(e);
              toggle();
            },
            'aria-expanded': open,
            'aria-haspopup': 'dialog',
          },
        )
      : trigger;

    return (
      <div
        ref={setRefs}
        className={cn('relative inline-block', className)}
        {...rest}
      >
        {triggerNode}
        {open && (
          <div
            role="dialog"
            className={cn(
              'absolute z-50 min-w-[12rem] rounded-2xl border border-slate-200 bg-white p-4 shadow-xl shadow-slate-900/10',
              'animate-in fade-in zoom-in-95 duration-150',
              placementStyles[placement],
              panelClassName,
            )}
          >
            {children}
          </div>
        )}
      </div>
    );
  },
);

Popover.displayName = 'Popover';