Modal default
modal={false} (non-blocking dismiss). 배경 클릭·Esc 자동 닫힘. 진짜 모달 focus trap은 Dialog 사용.
Focus management
Radix 자동 — Tab navigation, Esc dismiss는 trigger로 focus 복귀. onOpenAutoFocus/onCloseAutoFocus/onInteractOutside로 override 가능.
Tooltip 차이
Tooltip은 hover-only 단순 label(non-interactive). Popover는 click/keyboard interactive content(form, menu, links) — trigger anchor 기반.
Anchor optional
Trigger와 별개 anchor 위치 지정이 필요하면 <PopoverAnchor> 활용.
Arrow 옵션
<PopoverContent showArrow> 또는 standalone <PopoverArrow>. trigger anchor를 시각적으로 명시할 때 사용.
Anchor positioning (MUI 정합)
Default `side='bottom'` = MUI anchorOrigin 'bottom-left' 정합 — anchor 아래 정렬 기준. `side`는 **preferred side**이며 Radix가 viewport collision 시 자동 flip/shift할 수 있음 (`collisionPadding=8`, `avoidCollisions=true`). 정확한 위치 보장 필요 시 `avoidCollisions={false}` 사용. `align='start|center|end'`로 정렬 (default center).
PopoverArrow 표시
`<PopoverContent showArrow>` opt-in (default false). Anchor 시각 cue 필요 시 활성화. Tooltip은 arrow 기본 포함, Popover는 명시 — interactive content 강조.
Draggable 미지원 (산업 표준)
Radix/shadcn/MUI Popover 모두 draggable 기능 미포함 — Popover는 anchor 기반 floating UI로 drag 시 anchor와 끊겨 의미 흐려짐. drag 패턴 필요 시 `@dnd-kit`/`react-draggable` 별도 통합 또는 별도 `MovablePanel` 컴포넌트로 분리 권장.

메뉴

라이브 검증 (compact action list — side="bottom" default)
코드 보기tsx
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";

// Popover는 modal={false} 기본 — 비차단 dismiss.
// 배경 클릭 또는 Esc 로 닫힘. Tab으로 내부 버튼 순회 가능.
// side="bottom" = MUI anchorOrigin 'bottom-left' 정합 (anchor 아래 표시).
export function ActionMenu() {
  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button variant="outline">메뉴</Button>
      </PopoverTrigger>
      <PopoverContent side="bottom">
        <div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
          <button
            type="button"
            style={{
              textAlign: "left",
              padding: "6px 8px",
              borderRadius: "var(--radius-sm)",
              border: "none",
              background: "transparent",
              color: "var(--text-primary)",
              cursor: "pointer",
              fontSize: "0.875rem",
            }}
          >
            편집
          </button>
          <button
            type="button"
            style={{
              textAlign: "left",
              padding: "6px 8px",
              borderRadius: "var(--radius-sm)",
              border: "none",
              background: "transparent",
              color: "var(--text-primary)",
              cursor: "pointer",
              fontSize: "0.875rem",
            }}
          >
            복제
          </button>
          <button
            type="button"
            style={{
              textAlign: "left",
              padding: "6px 8px",
              borderRadius: "var(--radius-sm)",
              border: "none",
              background: "transparent",
              color: "var(--color-danger, var(--text-primary))",
              cursor: "pointer",
              fontSize: "0.875rem",
            }}
          >
            삭제
          </button>
        </div>
      </PopoverContent>
    </Popover>
  );
}

필터

라이브 검증 (체크박스 폼 + Apply — side="bottom")
코드 보기tsx
import { useState } from "react";
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";

// 필터 그룹을 popover에 담아 inline에서 검색 조건 조정.
// Esc / outside click으로 자동 dismiss, 적용은 명시적 버튼.
// Tooltip과 달리 checkbox, button 등 interactive content 가능.
// side="bottom" — anchor 아래 표시 (default 동일, 명시적 선언).
export function StatusFilter() {
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState<string[]>([]);
  const [applied, setApplied] = useState<string[]>([]);

  const toggle = (s: string) =>
    setSelected((prev) =>
      prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]
    );

  const handleApply = () => {
    setApplied(selected);
    setOpen(false);
  };

  return (
    <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger asChild>
          <Button variant="outline" size="sm">
            상태 필터{applied.length > 0 ? ` (${applied.length})` : ""}
          </Button>
        </PopoverTrigger>
        <PopoverContent side="bottom">
          <fieldset style={{ border: "none", padding: 0, margin: 0, display: "grid", gap: "8px" }}>
            <legend style={{ fontSize: "0.875rem", color: "var(--text-secondary)", marginBottom: "4px", fontWeight: 600 }}>
              표시할 상태
            </legend>
            {["대기", "진행 중", "완료", "오류"].map((s) => (
              <label key={s} style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer", fontSize: "0.875rem" }}>
                <input
                  type="checkbox"
                  checked={selected.includes(s)}
                  onChange={() => toggle(s)}
                />
                {s}
              </label>
            ))}
          </fieldset>
          <div style={{ display: "flex", gap: "8px", marginTop: "12px", justifyContent: "flex-end" }}>
            <Button variant="outline" size="sm" onClick={() => { setSelected([]); }}>
              초기화
            </Button>
            <Button variant="primary" size="sm" onClick={handleApply}>
              적용
            </Button>
          </div>
        </PopoverContent>
      </Popover>
      {applied.length > 0 && (
        <span style={{ fontSize: "0.875rem", color: "var(--text-secondary)" }}>
          선택: {applied.join(", ")}
        </span>
      )}
    </div>
  );
}

미리보기

라이브 검증 (클릭 시 메타 정보 노출 + showArrow — side="right")
코드 보기tsx
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";

// 학생 이름 클릭 시 학번·과정·출결 요약 미리보기.
// Tooltip과 달리 링크·버튼 같은 interactive content 포함 가능.
// side="right" — anchor 오른쪽에 표시 (테이블/목록 행 inline preview 패턴).
// showArrow={true}로 trigger anchor를 시각적으로 명시.
// viewport 좁으면 collisionPadding=8 자동 fallback.
const students = [
  { name: "김지수", id: "20220001", dept: "의학과 4학년", attendance: "출석 28 / 결석 2" },
  { name: "이민준", id: "20220042", dept: "의학과 4학년", attendance: "출석 25 / 결석 5" },
];

export function StudentPreview() {
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
      {students.map((student) => (
        <Popover key={student.id}>
          <PopoverTrigger asChild>
            <button
              type="button"
              style={{
                background: "none",
                border: "none",
                color: "var(--accent-fg)",
                textDecoration: "underline",
                cursor: "pointer",
                fontSize: "0.875rem",
                textAlign: "left",
                padding: 0,
              }}
            >
              {student.name}
            </button>
          </PopoverTrigger>
          <PopoverContent side="right" showArrow>
            <div style={{ display: "grid", gap: "6px" }}>
              <p style={{ fontWeight: 600, fontSize: "0.875rem", color: "var(--text-primary)", margin: 0 }}>
                {student.name}
              </p>
              <p style={{ fontSize: "0.75rem", color: "var(--text-secondary)", margin: 0 }}>
                학번: {student.id}
              </p>
              <p style={{ fontSize: "0.75rem", color: "var(--text-secondary)", margin: 0 }}>
                {student.dept}
              </p>
              <p style={{ fontSize: "0.75rem", color: "var(--text-secondary)", margin: 0 }}>
                {student.attendance}
              </p>
            </div>
          </PopoverContent>
        </Popover>
      ))}
    </div>
  );
}

확인

라이브 검증 (low-risk 인라인 confirm — side="top")
코드 보기tsx
import { useState } from "react";
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";

// 위험도 낮은 액션 (예: '저장 후 닫기')의 inline 확인.
// destructive / 비가역 액션은 Dialog 사용 — Popover는 non-blocking.
// side="top" — anchor 위에 표시 (confirm overlay가 트리거 버튼을 가리지 않도록).
// onOpenChange로 confirm 후 닫기.
export function SaveConfirmation() {
  const [open, setOpen] = useState(false);
  const [saved, setSaved] = useState(false);

  const handleConfirm = () => {
    setSaved(true);
    setOpen(false);
  };

  return (
    <div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger asChild>
          <Button variant="primary" size="sm">
            저장 후 닫기
          </Button>
        </PopoverTrigger>
        <PopoverContent side="top" showArrow>
          <p style={{ margin: "0 0 12px", fontSize: "0.875rem", color: "var(--text-primary)", fontWeight: 600 }}>
            변경사항을 저장할까요?
          </p>
          <p style={{ margin: "0 0 12px", fontSize: "0.8125rem", color: "var(--text-secondary)" }}>
            저장 후 편집 화면이 닫힙니다.
          </p>
          <div style={{ display: "flex", gap: "8px", justifyContent: "flex-end" }}>
            <Button variant="outline" size="sm" onClick={() => setOpen(false)}>
              취소
            </Button>
            <Button variant="primary" size="sm" onClick={handleConfirm}>
              저장
            </Button>
          </div>
        </PopoverContent>
      </Popover>
      {saved && (
        <span style={{ fontSize: "0.875rem", color: "var(--text-secondary)" }}>
          저장 완료
        </span>
      )}
    </div>
  );
}