Popover
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
알림 설정
언제 알림을 받을지 선택하세요.
Popover
클릭으로 열리는 anchored 패널. Tooltip(hover) · DropdownMenu(메뉴 항목)와 의미 분리 — 임의의 콘텐츠를 anchored 영역에 담을 때 사용합니다.
Basic
외부 클릭 / ESC 자동 닫기. placement는 8방향 지원 (default: bottom-start).
Source Code
Live Preview
Placements
bottom / top / left / right + start/end 변형.
bottom-start
Live Preview
bottom-end
Live Preview
top-start
Live Preview
top-end
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';