모달 여부
modal={false}가 기본입니다. 배경 클릭·Esc 자동 닫힘을 제공하며, 전체 포커스 트랩이 필요하면 Dialog를 사용합니다.
포커스 관리
Radix가 Tab 이동과 Esc 닫기 후 trigger 포커스 복귀를 처리합니다. onOpenAutoFocus/onCloseAutoFocus/onInteractOutside로 조정할 수 있습니다.
Tooltip 차이
Tooltip은 hover 중심의 단순 label입니다. Popover는 click/keyboard로 여는 form, menu, links 같은 조작 콘텐츠를 담습니다.
Anchor 선택
Trigger와 별개 anchor 위치 지정이 필요하면 <PopoverAnchor>를 활용합니다.
위치
`side='bottom'`이 기본이며 트리거 아래에 표시합니다. 충돌이 생기면 Radix가 자동으로 flip/shift하고, `align='start|center|end'`로 정렬을 조정합니다.
Arrow 옵션
`<PopoverContent showArrow>`로 트리거와 popover의 관계를 시각적으로 보강할 수 있습니다.
드래그 이동
Popover는 트리거 기준 floating UI이며 드래그 이동을 제공하지 않습니다. 이동 가능한 패널이 필요하면 별도 컴포넌트로 분리합니다.

메뉴

트리거 아래에 여는 조밀한 액션 목록
코드 보기tsx
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";

// Popover는 modal={false} 기본 — 비차단 dismiss.
// 배경 클릭 또는 Esc 로 닫힘. Tab으로 버튼 순회 가능.
// side="bottom"으로 트리거 아래에 표시합니다.
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>
  );
}

필터

체크박스 폼과 적용 액션
코드 보기tsx
import { useState } from "react";
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";

// 필터 그룹을 Popover에 담아 인접한 자리에서 검색 조건을 조정합니다.
// Esc와 바깥 클릭으로 자동 닫힘을 지원하고, 적용은 명시적 버튼으로 처리합니다.
// Tooltip과 달리 checkbox, button 등 조작 가능한 콘텐츠를 담을 수 있습니다.
// side="bottom"으로 트리거 아래에 표시합니다.
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>
  );
}

적용 중

필터 적용 대기 상태
코드 보기tsx
import { useState } from "react";
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";

// 적용 버튼의 loading 상태로 요청 진행 중임을 표시합니다.
// Popover 자체 API를 늘리지 않고 적용 버튼의 상태로 표현합니다.
export function PendingFilter() {
  const [open, setOpen] = useState(false);
  const [pending, setPending] = useState(false);

  const handleApply = () => {
    setPending(true);
    window.setTimeout(() => {
      setPending(false);
      setOpen(false);
    }, 1000);
  };

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button variant="outline" size="sm">필터 조정</Button>
      </PopoverTrigger>
      <PopoverContent side="bottom">
        <div style={{ display: "grid", gap: "12px" }}>
          <label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "0.875rem" }}>
            <input type="checkbox" defaultChecked />
            제출 완료만 보기
          </label>
          <Button variant="primary" size="sm" loading={pending} onClick={handleApply}>
            적용
          </Button>
        </div>
      </PopoverContent>
    </Popover>
  );
}

미리보기

화살표가 있는 메타 정보 미리보기
코드 보기tsx
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";

// 학생 이름 클릭 시 학번·과정·출결 요약 미리보기.
// Tooltip과 달리 링크·버튼 같은 조작 가능한 콘텐츠를 담을 수 있습니다.
// side="right" — anchor 오른쪽에 표시 (테이블/목록 행 미리보기 패턴).
// showArrow={true}로 트리거와 popover의 관계를 시각적으로 보강합니다.
// 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>
  );
}

확인

낮은 위험의 인라인 confirm
코드 보기tsx
import { useState } from "react";
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";

// 되돌릴 수 있는 가벼운 액션은 Popover 안에서 확인할 수 있습니다.
// destructive / 비가역 액션은 Dialog 사용 — Popover는 배경 작업을 막지 않습니다.
// 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>
  );
}